SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
4 h agolast push
JavaScript 56.7% TypeScript 42.6%
24.3 KB · 448 lines tsx
Raw Blame History
1"use client";23import { useEffect, useState, useCallback } from "react";4import { useParams } from "next/navigation";5import { motion } from "framer-motion";6import {7  Download, Lock, AlertCircle, Loader2, FileText, Image as ImageIcon, Film, Music, FileCode, File as FileIcon,8  ZoomIn, ZoomOut, Clock, Folder, ChevronRight, Home, Eye, FileSpreadsheet, Archive,9} from "lucide-react";10import Button from "@/components/ui/Button";11import Logo from "@/components/ui/Logo";12import ThemeToggle from "@/components/theme/ThemeToggle";1314interface FileInfo { id: string; name: string; mimeType: string; size: number; mode: string; expiresAt: string | null }15interface FolderListing {16  id: string; name: string; mode: string; expiresAt: string | null;17  folder: { id: string; name: string };18  crumbs: { id: string; name: string }[];19  folders: { id: string; name: string; files: number; children: number }[];20  files: { id: string; name: string; mimeType: string; size: number; updatedAt: string }[];21}2223function formatSize(bytes: number): string {24  if (!bytes) return "0 o";25  const k = 1024;26  const sizes = ["o", "Ko", "Mo", "Go", "To"];27  const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));28  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];29}3031function formatExpiry(expiresAt: string | null): string | null {32  if (!expiresAt) return null;33  const diff = new Date(expiresAt).getTime() - Date.now();34  if (diff <= 0) return "Expiré";35  const hours = Math.floor(diff / 3600000);36  const days = Math.floor(hours / 24);37  if (days > 0) return `Expire dans ${days} j`;38  if (hours > 0) return `Expire dans ${hours} h`;39  return `Expire dans ${Math.floor(diff / 60000)} min`;40}4142type Category = "image" | "pdf" | "video" | "audio" | "text" | "spreadsheet" | "archive" | "other";43function getFileCategory(mimeType: string): Category {44  if (mimeType.startsWith("image/")) return "image";45  if (mimeType === "application/pdf") return "pdf";46  if (mimeType.startsWith("video/")) return "video";47  if (mimeType.startsWith("audio/")) return "audio";48  if (mimeType.includes("spreadsheet") || mimeType === "text/csv" || mimeType.includes("excel")) return "spreadsheet";49  if (mimeType.includes("zip") || mimeType.includes("compressed") || mimeType.includes("tar")) return "archive";50  if (mimeType.startsWith("text/") || /json|xml|javascript|yaml|x-sh|markdown/.test(mimeType)) return "text";51  return "other";52}5354function CategoryIcon({ category, className = "w-12 h-12" }: { category: Category; className?: string }) {55  switch (category) {56    case "image": return <ImageIcon className={`${className} text-emerald-500`} />;57    case "pdf": return <FileText className={`${className} text-rose-500`} />;58    case "video": return <Film className={`${className} text-purple-500`} />;59    case "audio": return <Music className={`${className} text-pink-500`} />;60    case "text": return <FileCode className={`${className} text-amber-500`} />;61    case "spreadsheet": return <FileSpreadsheet className={`${className} text-green-600`} />;62    case "archive": return <Archive className={`${className} text-orange-500`} />;63    default: return <FileIcon className={`${className} text-gray-400`} />;64  }65}6667export default function SharedPage() {68  const params = useParams();69  const token = params.token as string;7071  const [state, setState] = useState<"loading" | "password" | "preview" | "download" | "folder" | "error">("loading");72  const [error, setError] = useState("");73  const [errorType, setErrorType] = useState<"expired" | "invalid" | "network">("invalid");74  const [fileInfo, setFileInfo] = useState<FileInfo | null>(null);75  const [listing, setListing] = useState<FolderListing | null>(null);76  const [protectedName, setProtectedName] = useState("");77  const [protectedType, setProtectedType] = useState<"file" | "folder">("file");78  const [password, setPassword] = useState("");79  const [passwordLoading, setPasswordLoading] = useState(false);80  const [textContent, setTextContent] = useState<string | null>(null);81  const [imageZoom, setImageZoom] = useState(1);82  const [currentFolder, setCurrentFolder] = useState<string | null>(null);83  const [folderLoading, setFolderLoading] = useState(false);8485  const base = `/api/shared/${token}`;86  const fileContentUrl = (download?: boolean, fileId?: string) =>87    `${base}?content=true${fileId ? `&fileId=${fileId}` : ""}${download ? "&action=download" : ""}`;88  const zipUrl = (folderId?: string | null) => `${base}?action=download${folderId ? `&folderId=${folderId}` : ""}`;8990  /** Charge l'état du partage (le cookie posé après vérification du mot de passe suffit). */91  const load = useCallback(async (folderId?: string | null) => {92    try {93      const res = await fetch(`${base}${folderId ? `?folderId=${folderId}` : ""}`, { cache: "no-store" });94      if (!res.ok) {95        const data = await res.json().catch(() => ({}));96        setErrorType(res.status === 410 ? "expired" : "invalid");97        setError(data.error || (res.status === 410 ? "Ce lien a expiré" : "Lien invalide"));98        setState("error");99        return;100      }101      const data = await res.json();102      if (data.requiresPassword) {103        setProtectedName(data.name || data.fileName || "");104        setProtectedType(data.type === "folder" ? "folder" : "file");105        setState("password");106        return;107      }108      if (data.type === "folder") {109        setListing(data);110        setCurrentFolder(data.folder.id);111        setState("folder");112        return;113      }114      setFileInfo(data);115      setState(data.mode === "DOWNLOAD" ? "download" : "preview");116    } catch {117      setErrorType("network");118      setError("Erreur réseau");119      setState("error");120    }121  }, [base]);122123  useEffect(() => { load(); }, [load]);124125  // Texte : chargement du contenu pour l'aperçu126  useEffect(() => {127    if (!fileInfo || state !== "preview" || getFileCategory(fileInfo.mimeType) !== "text") return;128    fetch(fileContentUrl()).then((r) => r.text()).then(setTextContent).catch(() => setTextContent("Impossible de charger le contenu."));129    // eslint-disable-next-line react-hooks/exhaustive-deps130  }, [fileInfo, state]);131132  const handlePasswordSubmit = async (e: React.FormEvent) => {133    e.preventDefault();134    setPasswordLoading(true);135    setError("");136    try {137      const res = await fetch(`${base}/verify`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });138      const data = await res.json().catch(() => ({}));139      if (!res.ok) { setError(data.error || "Mot de passe incorrect"); return; }140      // Le cookie signé est posé : on recharge normalement141      setState("loading");142      await load();143    } catch {144      setError("Erreur réseau");145    } finally {146      setPasswordLoading(false);147    }148  };149150  const openFolder = async (folderId: string) => {151    setFolderLoading(true);152    try {153      const res = await fetch(`${base}?folderId=${folderId}`, { cache: "no-store" });154      if (res.ok) {155        const data = await res.json();156        if (data.type === "folder") { setListing(data); setCurrentFolder(data.folder.id); }157      }158    } finally {159      setFolderLoading(false);160    }161  };162163  const handleDownload = () => window.open(fileContentUrl(true), "_blank");164165  if (state === "loading") {166    return (167      <Shell centered>168        <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4">169          <Logo size={56} />170          <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />171          <p className="text-sm text-gray-500">Chargement du partage…</p>172        </motion.div>173      </Shell>174    );175  }176177  if (state === "error") {178    return (179      <Shell centered>180        <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-8 text-center max-w-sm w-full">181          <div className={`w-16 h-16 rounded-3xl mx-auto mb-5 flex items-center justify-center ${errorType === "expired" ? "bg-amber-500/10 text-amber-500" : "bg-rose-500/10 text-rose-500"}`}>182            {errorType === "expired" ? <Clock className="w-8 h-8" /> : <AlertCircle className="w-8 h-8" />}183          </div>184          <h1 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">185            {errorType === "expired" ? "Lien expiré" : errorType === "network" ? "Erreur de connexion" : "Lien invalide"}186          </h1>187          <p className="text-gray-500 dark:text-gray-400 text-sm">{error}</p>188          {errorType === "expired" && <p className="text-gray-400 text-xs mt-3">Demandez un nouveau lien au propriétaire.</p>}189        </motion.div>190      </Shell>191    );192  }193194  if (state === "password") {195    return (196      <Shell>197        <Header />198        <div className="flex-1 flex items-center justify-center p-4">199          <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="w-full max-w-sm">200            <div className="card p-6 sm:p-8">201              <div className="text-center mb-6">202                <div className="w-16 h-16 rounded-3xl bg-blue-500/10 text-blue-600 dark:text-blue-300 flex items-center justify-center mx-auto mb-4">203                  <Lock className="w-8 h-8" />204                </div>205                <h1 className="text-lg font-semibold text-gray-900 dark:text-white">{protectedType === "folder" ? "Dossier protégé" : "Fichier protégé"}</h1>206                <p className="text-sm text-gray-500 mt-1 truncate max-w-[280px] mx-auto">{protectedName}</p>207              </div>208              <form onSubmit={handlePasswordSubmit} className="space-y-3">209                <input type="password" value={password} onChange={(e) => { setPassword(e.target.value); setError(""); }} placeholder="Mot de passe du partage" className="field h-12 text-center" autoFocus />210                {error && <p className="text-rose-500 text-sm text-center" role="alert">{error}</p>}211                <Button type="submit" variant="accent" size="lg" className="w-full" loading={passwordLoading} disabled={!password}>Accéder</Button>212              </form>213            </div>214          </motion.div>215        </div>216        <Footer expiresAt={null} />217      </Shell>218    );219  }220221  /* ================= DOSSIER PARTAGÉ ================= */222  if (state === "folder" && listing) {223    const isRoot = currentFolder === listing.id;224    const previewMode = listing.mode === "PREVIEW";225    return (226      <Shell>227        <Header228          title={listing.folder.name}229          subtitle={`${listing.files.length} fichier${listing.files.length !== 1 ? "s" : ""}${listing.folders.length ? ` · ${listing.folders.length} sous-dossier${listing.folders.length !== 1 ? "s" : ""}` : ""}`}230          isFolder231          onDownload={() => window.open(zipUrl(isRoot ? null : currentFolder), "_blank")}232          downloadLabel="Télécharger en ZIP"233        />234        <main className="flex-1 w-full max-w-5xl mx-auto px-3 sm:px-6 py-4 sm:py-6">235          {/* Fil d'Ariane borné à la racine du partage */}236          <nav className="flex items-center gap-1 text-sm mb-4 overflow-x-auto scrollbar-none whitespace-nowrap" aria-label="Fil d'Ariane">237            {listing.crumbs.map((c, i) => {238              const last = i === listing.crumbs.length - 1;239              return (240                <span key={c.id} className="flex items-center gap-1">241                  {i > 0 && <ChevronRight className="w-3.5 h-3.5 text-gray-300 dark:text-gray-600" />}242                  <button243                    onClick={() => !last && openFolder(c.id)}244                    className={`flex items-center gap-1.5 px-2 py-1 rounded-lg transition-colors ${last ? "font-semibold text-gray-900 dark:text-white" : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/5"}`}245                    disabled={last}246                  >247                    {i === 0 && <Home className="w-4 h-4" />}248                    {c.name}249                  </button>250                </span>251              );252            })}253          </nav>254255          <motion.div key={currentFolder} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className={`card overflow-hidden ${folderLoading ? "opacity-60" : ""}`}>256            {listing.folders.length === 0 && listing.files.length === 0 ? (257              <div className="py-16 text-center text-gray-500">258                <Folder className="w-10 h-10 mx-auto mb-3 text-gray-300 dark:text-gray-600" />259                <p className="text-sm">Ce dossier est vide.</p>260              </div>261            ) : (262              <ul className="divide-y divide-line dark:divide-white/[0.06]">263                {listing.folders.map((f) => (264                  <li key={f.id}>265                    <button onClick={() => openFolder(f.id)} className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/[0.04] transition-colors">266                      <span className="w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0"><Folder className="w-[18px] h-[18px] text-blue-500 fill-blue-500/20" /></span>267                      <span className="flex-1 min-w-0">268                        <span className="block text-[14px] font-medium text-gray-900 dark:text-white truncate">{f.name}</span>269                        <span className="block text-[12px] text-gray-500">{f.files} fichier{f.files !== 1 ? "s" : ""}{f.children ? ` · ${f.children} sous-dossier${f.children !== 1 ? "s" : ""}` : ""}</span>270                      </span>271                      <a href={zipUrl(f.id)} onClick={(e) => e.stopPropagation()} className="hidden sm:inline-flex w-9 h-9 items-center justify-center rounded-lg text-gray-400 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10" title="Télécharger ce dossier en ZIP" aria-label="ZIP">272                        <Download className="w-4 h-4" />273                      </a>274                      <ChevronRight className="w-4 h-4 text-gray-300 dark:text-gray-600" />275                    </button>276                  </li>277                ))}278                {listing.files.map((f) => {279                  const cat = getFileCategory(f.mimeType);280                  return (281                    <li key={f.id} className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/[0.04] transition-colors">282                      <span className="w-9 h-9 rounded-lg bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center shrink-0"><CategoryIcon category={cat} className="w-[18px] h-[18px]" /></span>283                      <span className="flex-1 min-w-0">284                        <span className="block text-[14px] text-gray-900 dark:text-white truncate">{f.name}</span>285                        <span className="block text-[12px] text-gray-500 tabular-nums">{formatSize(f.size)}</span>286                      </span>287                      {previewMode && ["image", "pdf", "video", "audio", "text"].includes(cat) && (288                        <a href={fileContentUrl(false, f.id)} target="_blank" rel="noopener" className="inline-flex w-9 h-9 items-center justify-center rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-500/10" title="Aperçu" aria-label="Aperçu">289                          <Eye className="w-4 h-4" />290                        </a>291                      )}292                      <a href={fileContentUrl(true, f.id)} className="inline-flex w-9 h-9 items-center justify-center rounded-lg text-gray-400 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10" title="Télécharger" aria-label="Télécharger">293                        <Download className="w-4 h-4" />294                      </a>295                    </li>296                  );297                })}298              </ul>299            )}300          </motion.div>301        </main>302        <Footer expiresAt={listing.expiresAt} />303      </Shell>304    );305  }306307  /* ================= FICHIER : téléchargement seul ================= */308  if (state === "download") {309    const category = fileInfo ? getFileCategory(fileInfo.mimeType) : "other";310    return (311      <Shell>312        <Header title={fileInfo?.name} subtitle={fileInfo ? formatSize(fileInfo.size) : undefined} onDownload={handleDownload} />313        <div className="flex-1 flex items-center justify-center p-4">314          <motion.div initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} className="card p-8 sm:p-10 text-center max-w-md w-full">315            <div className="w-24 h-24 rounded-3xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center mx-auto mb-6"><CategoryIcon category={category} /></div>316            <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1 break-words">{fileInfo?.name}</h2>317            <p className="text-sm text-gray-500 mb-6 tabular-nums">{fileInfo ? formatSize(fileInfo.size) : ""}</p>318            <Button onClick={handleDownload} variant="accent" size="lg" className="w-full sm:w-auto"><Download className="w-5 h-5" /> Télécharger</Button>319          </motion.div>320        </div>321        <Footer expiresAt={fileInfo?.expiresAt || null} />322      </Shell>323    );324  }325326  /* ================= FICHIER : aperçu ================= */327  const category = fileInfo ? getFileCategory(fileInfo.mimeType) : "other";328  return (329    <Shell>330      <Header title={fileInfo?.name} subtitle={fileInfo ? formatSize(fileInfo.size) : undefined} onDownload={handleDownload} />331      <main className="flex-1 flex items-center justify-center p-3 sm:p-6 overflow-auto">332        <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} className="w-full h-full flex items-center justify-center">333          {category === "image" && fileInfo && (334            <div className="relative flex flex-col items-center gap-3 max-w-full">335              <div className="overflow-auto max-h-[75vh] max-w-full rounded-2xl shadow-pop">336                <img src={fileContentUrl()} alt={fileInfo.name} className="max-w-full object-contain rounded-2xl transition-transform duration-200" style={{ transform: `scale(${imageZoom})`, transformOrigin: "center" }} />337              </div>338              <div className="flex items-center gap-1 glass rounded-full px-1.5 py-1">339                <button onClick={() => setImageZoom((z) => Math.max(0.25, z - 0.25))} className="w-9 h-9 rounded-full flex items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-white/10" aria-label="Réduire"><ZoomOut className="w-4 h-4" /></button>340                <span className="text-xs text-gray-600 dark:text-gray-300 min-w-[3rem] text-center tabular-nums">{Math.round(imageZoom * 100)}%</span>341                <button onClick={() => setImageZoom((z) => Math.min(4, z + 0.25))} className="w-9 h-9 rounded-full flex items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-white/10" aria-label="Agrandir"><ZoomIn className="w-4 h-4" /></button>342              </div>343            </div>344          )}345          {category === "pdf" && fileInfo && <iframe src={fileContentUrl()} className="w-full h-[80vh] border-0 rounded-2xl bg-white shadow-pop" title={fileInfo.name} />}346          {category === "video" && fileInfo && (347            <video controls className="max-w-full max-h-[75vh] rounded-2xl shadow-pop bg-black" preload="metadata">348              <source src={fileContentUrl()} type={fileInfo.mimeType} />349              Votre navigateur ne supporte pas la lecture vidéo.350            </video>351          )}352          {category === "audio" && fileInfo && (353            <div className="w-full max-w-lg">354              <div className="card p-8 text-center">355                <div className="w-20 h-20 rounded-3xl bg-pink-500/10 flex items-center justify-center mx-auto mb-6"><Music className="w-10 h-10 text-pink-500" /></div>356                <h3 className="text-gray-900 dark:text-white font-medium mb-4 truncate">{fileInfo.name}</h3>357                <audio controls className="w-full" preload="metadata"><source src={fileContentUrl()} type={fileInfo.mimeType} />Votre navigateur ne supporte pas la lecture audio.</audio>358              </div>359            </div>360          )}361          {category === "text" && (362            <div className="w-full max-w-4xl max-h-[75vh] overflow-auto">363              <div className="card p-4 sm:p-6">364                {textContent === null ? <div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 text-blue-500 animate-spin" /></div>365                  : <pre className="text-[13px] text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words font-mono leading-relaxed">{textContent}</pre>}366              </div>367            </div>368          )}369          {(category === "other" || category === "spreadsheet" || category === "archive") && fileInfo && (370            <div className="card p-8 sm:p-10 text-center max-w-md w-full">371              <div className="w-24 h-24 rounded-3xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center mx-auto mb-6"><CategoryIcon category={category} /></div>372              <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1 break-words">{fileInfo.name}</h2>373              <p className="text-sm text-gray-500 mb-2 tabular-nums">{formatSize(fileInfo.size)}</p>374              <p className="text-xs text-gray-400 mb-6">Aperçu non disponible pour ce type de fichier.</p>375              <Button onClick={handleDownload} variant="accent"><Download className="w-4 h-4" /> Télécharger</Button>376            </div>377          )}378        </motion.div>379      </main>380      <Footer expiresAt={fileInfo?.expiresAt || null} />381    </Shell>382  );383}384385// --- Sous-composants ---386387function Shell({ children, centered }: { children: React.ReactNode; centered?: boolean }) {388  return (389    <div className={`min-h-[100dvh] bg-canvas relative flex flex-col ${centered ? "items-center justify-center p-4" : ""}`}>390      <div className="absolute inset-0 pointer-events-none bg-aurora opacity-50 dark:opacity-35" />391      <div className="absolute inset-0 pointer-events-none bg-grid bg-[size:40px_40px] opacity-[0.25] dark:opacity-[0.12] [mask-image:radial-gradient(ellipse_at_top,black_20%,transparent_70%)]" />392      {centered && (393        <div className="absolute top-4 left-4 right-4 flex items-center justify-between">394          <Logo size={32} wordmark wordmarkClassName="text-[15px]" />395          <ThemeToggle />396        </div>397      )}398      <div className={`relative flex flex-col ${centered ? "w-full items-center" : "flex-1 min-h-[100dvh]"}`}>{children}</div>399    </div>400  );401}402403function Header({ title, subtitle, isFolder, onDownload, downloadLabel = "Télécharger" }: { title?: string; subtitle?: string; isFolder?: boolean; onDownload?: () => void; downloadLabel?: string }) {404  return (405    <header className="sticky top-0 z-10 glass border-x-0 border-t-0 rounded-none">406      <div className="max-w-6xl mx-auto flex items-center justify-between gap-3 px-4 h-16">407        <div className="flex items-center gap-3 min-w-0">408          <Logo size={34} wordmark={!title} wordmarkClassName="text-[15px]" />409          {title && (410            <div className="min-w-0 border-l border-line dark:border-white/10 pl-3 flex items-center gap-2">411              {isFolder && <Folder className="w-5 h-5 text-blue-500 fill-blue-500/20 shrink-0" />}412              <div className="min-w-0">413                <p className="text-[14px] text-gray-900 dark:text-white font-medium truncate max-w-[200px] sm:max-w-[420px]">{title}</p>414                {subtitle && <p className="text-[11.5px] text-gray-500 tabular-nums">{subtitle}</p>}415              </div>416            </div>417          )}418        </div>419        <div className="flex items-center gap-1.5">420          <ThemeToggle />421          {onDownload && (422            <Button size="sm" variant="accent" onClick={onDownload}>423              <Download className="w-4 h-4" />424              <span className="hidden sm:inline">{downloadLabel}</span>425            </Button>426          )}427        </div>428      </div>429    </header>430  );431}432433function Footer({ expiresAt }: { expiresAt: string | null }) {434  const expiryText = formatExpiry(expiresAt);435  return (436    <footer className="px-4 py-4 text-center pb-safe">437      <p className="text-[12px] text-gray-400 dark:text-gray-500 flex items-center justify-center gap-2 flex-wrap">438        <span>Propulsé par <span className="font-medium text-gray-600 dark:text-gray-300">SPB Cloud</span> · partage chiffré</span>439        {expiryText && (440          <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400">441            <Clock className="w-3 h-3" /> {expiryText}442          </span>443        )}444      </p>445    </footer>446  );447}448